SPB Git forge

spb/qc26

Public
10commits 1branches 0releases
2.8 MBsize
maindefault branch
18 days agolast push
Python 51.6% TypeScript 46.7% CSS 1.7%
15.3 KB · 156 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { apiSafe } from "@/lib/api";5import { PartyBadge, PartyDot, PartyLogo } from "@/components/party";6import { Badge, Card, KV, LiveBadge, ProbabilityBar, ProjectionBadge, SectionHeader, SourceBadge } from "@/components/ui";7import { RidingMap } from "@/components/riding-map";8import { RidingLive } from "@/components/riding-live";9import { dateFr, dateTimeFr, delta, int, pct, prob, ratingLabel } from "@/lib/format";10import { partyVar, partyLabel, partyName } from "@/lib/parties";1112export const revalidate = 60;1314interface Detail {15  code: number; name: string; slug: string; region: string | null; regionName: string | null; electors: number | null; areaKm2: number | null; isNew: boolean; changed: boolean; centroid: [number, number];16  incumbent: { party: string | null; name: string | null; running: boolean | null };17  baseline2022: { party: string; share: number; votes: number; method: string; coverage: number | null; source: string }[];18  candidates: { id: number; name: string; party: string | null; partyLabel: string | null; independent: boolean; incumbent: boolean; updatedAt: string | null }[];19  candidatesSource: { name: string; url: string };20  history: Record<string, { party: string | null; label: string; candidate: string; votes: number; share: number; elected: boolean; turnout: number | null }[]>;21  historyNote: string;22  forecast?: { runId: number; completedAt: string | null; modelVersion: string; favorite: string; p: number; runnerUp: string | null; margin: number; rating: string; volatility: number; swing: number; factors: { factor: string; label: string; detail: string; direction: string }[]; parties: { party: string; voteMean: number; vote80: [number, number]; pWin: number }[] };23  live: { status: string; updatedAt: string | null; bureaux: [number, number]; votesValid: number; turnout: number | null; leader: string | null; marginVotes: number | null; marginPct: number | null; candidates: { name: string; party_id: string; votes: number; share: number }[] | null; final: boolean } | null;24  nearby?: { code: number; name: string; slug: string }[];25  demographics?: { population2021: number; density: number | null; medianAge: number | null; pct65Plus: number | null; pctFrenchMt: number | null; pctEnglishMt: number | null; pctOtherMt: number | null; pctPlopFrench: number | null; pctImmigrants: number | null; pctVisibleMinority: number | null; medianHouseholdIncome: number | null; pctOwner: number | null; pctBachelorPlus: number | null; unemploymentRate: number | null; adaCount: number; source: string } | null;26  pollRegion?: string;27}2829export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {30  const { slug } = await params;31  const d = await apiSafe<Detail>(`/api/ridings/${slug}`);32  if (!d) return { title: "Circonscription" };33  const fav = d.forecast ? `${partyLabel(d.forecast.favorite)} favori (${prob(d.forecast.p)})` : "";34  return { title: `${d.name} — Prévision élection Québec 2026`, description: `${d.name} (${d.regionName}) : ${fav}. Candidat·es, résultat 2022 transposé, probabilité de victoire par parti et facteurs du modèle QC26.`, alternates: { canonical: `/circonscription/${d.slug}` } };35}3637export default async function Page({ params }: { params: Promise<{ slug: string }> }) {38  const { slug } = await params;39  const d = await apiSafe<Detail>(`/api/ridings/${slug}`);40  if (!d) notFound();41  const f = d.forecast;42  const years = Object.keys(d.history).sort().reverse();43  return (44    <div className="space-y-10">45      <div className="grid lg:grid-cols-[1fr_360px] gap-6 items-start">46        <div>47          <div className="flex items-center gap-2 flex-wrap"><Link href="/circonscriptions" className="text-[12.5px] text-ink-3 hover:text-ink">← Circonscriptions</Link><Badge>{d.regionName}</Badge>{d.isNew && <Badge tone="accent">Nouvelle ou renommée en 2026</Badge>}{!d.isNew && d.changed && <Badge>Limites modifiées</Badge>}{d.live && d.live.status !== "NOT_STARTED" && <LiveBadge label={d.live.final ? "Final" : "Direct"} />}</div>48          <h1 className="display text-[38px] md:text-[56px] mt-3">{d.name}</h1>49          <div className="mt-2 text-[14px] text-ink-2 num">{int(d.electors)} électeurs inscrits{d.areaKm2 ? ` · ${int(d.areaKm2)} km²` : ""} · sortant·e : {d.incumbent.name ? `${d.incumbent.name} (${partyLabel(d.incumbent.party)})${d.incumbent.running === false ? ", ne se représente pas" : d.incumbent.running ? ", candidat·e" : ""}` : "—"}</div>50          {f && (51            <div className="mt-6 card card-lg p-5 md:p-6">52              <div className="flex items-center justify-between flex-wrap gap-2"><div className="eyebrow">Projection QC26 · run #{f.runId} · {dateTimeFr(f.completedAt)}</div><ProjectionBadge /></div>53              <div className="mt-3 flex items-end gap-6 flex-wrap">54                <div><div className="flex items-center gap-2"><PartyLogo id={f.favorite} height={22} /><span className="font-semibold">{partyName(f.favorite)} favori</span></div><div className="num display text-[64px]" style={{ color: partyVar(f.favorite) }}>{prob(f.p)}</div><div className="text-[13px] text-ink-2 -mt-1">probabilité de victoire · <b>{ratingLabel(f.rating)}</b></div></div>55                <div className="ml-auto grid grid-cols-2 gap-x-6 gap-y-3 text-[13px]"><div><div className="eyebrow">Marge projetée</div><div className="num text-[22px] font-bold">{f.margin.toFixed(1).replace(".", ",")} pt</div><div className="text-ink-3">vs {partyLabel(f.runnerUp)}</div></div><div><div className="eyebrow">Swing vs 2022</div><div className="num text-[22px] font-bold">{delta(f.swing, 1, " pt")}</div><div className="text-ink-3">{partyLabel(f.favorite)}</div></div></div>56              </div>57              <div className="mt-5 space-y-2">58                {f.parties.filter((p) => p.party !== "aut" || p.voteMean > 2).map((p) => (59                  <div key={p.party} className="grid grid-cols-[46px_1fr_92px_60px] items-center gap-3 text-[13px]">60                    <span className="font-semibold num" style={{ color: partyVar(p.party) }}>{partyLabel(p.party)}</span>61                    <div className="relative h-[14px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(p.vote80[0] / 70) * 100}%`, width: `${((p.vote80[1] - p.vote80[0]) / 70) * 100}%`, background: partyVar(p.party), opacity: 0.3 }} /><div className="absolute inset-y-0 w-[2px] bg-ink" style={{ left: `${(p.voteMean / 70) * 100}%` }} /></div>62                    <span className="num text-ink-2">{p.voteMean.toFixed(1).replace(".", ",")} % <span className="text-ink-3 text-[11px]">({p.vote80[0].toFixed(0)}–{p.vote80[1].toFixed(0)})</span></span>63                    <span className="num font-semibold text-right">{prob(p.pWin)}</span>64                  </div>65                ))}66                <div className="text-[11.5px] text-ink-3">Vote estimé (moyenne, intervalle 80 %) et probabilité de victoire.</div>67              </div>68            </div>69          )}70        </div>71        <div className="space-y-4">72          <Card pad={false} className="overflow-hidden"><RidingMap code={d.code} /></Card>73          {d.nearby && <Card><div className="eyebrow mb-2">Circonscriptions voisines</div><div className="flex flex-wrap gap-1.5">{d.nearby.map((n) => <Link key={n.code} href={`/circonscription/${n.slug}`} className="text-[12.5px] rounded-full border border-border px-2.5 py-1 hover:bg-surface-2">{n.name}</Link>)}</div></Card>}74        </div>75      </div>7677      {d.live && d.live.status !== "NOT_STARTED" && <RidingLive code={d.code} initial={d.live} forecast={f ? { favorite: f.favorite, p: f.p } : null} />}7879      {f && (80        <section>81          <SectionHeader eyebrow="Pourquoi ?" title={`Pourquoi QC26 donne ${partyLabel(f.favorite)} favori ?`} description="Seuls les facteurs réellement utilisés par le modèle sont listés — aucune explication éditoriale." />82          <div className="grid md:grid-cols-2 gap-3">83            {f.factors.map((x, i) => (84              <div key={i} className="card p-4 flex gap-3"><span className={"num font-bold text-[18px] w-6 shrink-0 " + (x.direction === "+" ? "text-ok" : x.direction === "-" ? "text-live" : "text-ink-3")}>{x.direction}</span><div><div className="font-semibold text-[14px]">{x.label}</div><div className="text-[13px] text-ink-2 mt-0.5">{x.detail}</div></div></div>85            ))}86          </div>87        </section>88      )}8990      <section className="grid lg:grid-cols-2 gap-6">91        <div>92          <SectionHeader eyebrow="Candidatures" title="Candidat·es" description="Liste officielle d'Élections Québec, mise à jour toutes les 4 heures." action={<SourceBadge source={{ name: d.candidatesSource.name, url: d.candidatesSource.url, tier: "Officiel (DGEQ)" }} />} />93          <Card pad={false}>94            {d.candidates.length === 0 ? <div className="p-6 text-center text-ink-3 text-[13.5px]">Aucune candidature enregistrée pour l&apos;instant.</div> : (95              <table className="data-table"><thead><tr><th>Candidat·e</th><th>Parti</th><th></th></tr></thead><tbody>{d.candidates.sort((a, b) => (f?.parties.find((p) => p.party === b.party)?.pWin ?? 0) - (f?.parties.find((p) => p.party === a.party)?.pWin ?? 0)).map((c) => (<tr key={c.id}><td className="font-medium">{c.name}</td><td>{c.party ? <span className="inline-flex items-center gap-1.5"><PartyDot id={c.party} />{partyLabel(c.party)}</span> : <span className="text-ink-2">{c.independent ? "Indépendant·e" : c.partyLabel}</span>}</td><td className="r text-[11.5px] text-ink-3">{c.incumbent ? "Sortant·e" : ""}</td></tr>))}</tbody></table>96            )}97          </Card>98        </div>99        <div>100          <SectionHeader eyebrow="2022 sur la carte 2026" title="Résultat précédent transposé" description="Résultats officiels 2022 par section de vote, réaffectés aux limites 2026 (calcul QC26)." action={<SourceBadge source={{ name: "Élections Québec — résultats 2022 par section de vote + carte 2026", url: "https://www.dgeq.org/donnees.html", detail: d.baseline2022[0]?.source, tier: "Officiel (DGEQ) · transposition QC26" }} />} />101          <Card>102            <div className="space-y-2">{d.baseline2022.filter((b) => b.party !== "aut" || b.share > 1).map((b) => (<div key={b.party} className="grid grid-cols-[46px_1fr_64px] items-center gap-3 text-[13px]"><span className="font-semibold num" style={{ color: partyVar(b.party) }}>{partyLabel(b.party)}</span><div className="h-[12px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="h-full" style={{ width: `${(b.share / 70) * 100}%`, background: partyVar(b.party) }} /></div><span className="num text-right">{pct(b.share)}</span></div>))}</div>103            <div className="text-[12px] text-ink-3 mt-3">{d.baseline2022[0]?.coverage !== null && d.baseline2022[0]?.coverage !== undefined ? `${pct(d.baseline2022[0].coverage * 100, 0)} des votes proviennent de la circonscription 2022 principale.` : ""} Hypothèse : les votes par anticipation se répartissent comme ceux du jour du vote.</div>104          </Card>105        </div>106      </section>107108      {d.demographics && (109        <section>110          <SectionHeader eyebrow="Démographie" title="Recensement 2021" description="Statistique Canada, agrégé aux limites 2026 par intersection des aires de diffusion agrégées. La part francophone alimente le swing par segment du modèle." action={<SourceBadge source={{ name: "Statistique Canada — Recensement 2021 (profil ADA + limites)", url: "https://www12.statcan.gc.ca/census-recensement/2021/dp-pd/prof/details/download-telecharger.cfm?Lang=F", detail: `${d.demographics.adaCount} aires de diffusion agrégées intersectées`, tier: "Officiel (StatCan) · agrégation QC26" }} />} />111          <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">112            {([113              ["Population 2021", int(d.demographics.population2021), ""],114              ["Langue maternelle française", d.demographics.pctFrenchMt, " %"],115              ["Langue maternelle anglaise", d.demographics.pctEnglishMt, " %"],116              ["Autres langues maternelles", d.demographics.pctOtherMt, " %"],117              ["Immigrant·es", d.demographics.pctImmigrants, " %"],118              ["Minorités visibles", d.demographics.pctVisibleMinority, " %"],119              ["65 ans et plus", d.demographics.pct65Plus, " %"],120              ["Âge médian", d.demographics.medianAge, " ans"],121              ["Revenu médian des ménages", d.demographics.medianHouseholdIncome ? int(d.demographics.medianHouseholdIncome) : null, " $"],122              ["Propriétaires", d.demographics.pctOwner, " %"],123              ["Baccalauréat ou plus (25-64)", d.demographics.pctBachelorPlus, " %"],124              ["Taux de chômage", d.demographics.unemploymentRate, " %"],125              ["Densité", d.demographics.density ? int(d.demographics.density) : null, " hab./km²"],126              ["Regroupement sondeurs", d.pollRegion === "montreal_rmr" ? "Grand Montréal" : d.pollRegion === "quebec_rmr" ? "Région de Québec" : "Reste du Québec", ""],127            ] as [string, string | number | null, string][]).map(([label, value, suffix]) => (128              <div key={label} className="card p-3"><div className="text-[11px] text-ink-3 leading-tight">{label}</div><div className="num font-bold text-[18px] mt-1">{value === null || value === undefined ? "—" : typeof value === "number" ? value.toLocaleString("fr-CA", { maximumFractionDigits: 1 }) : value}<span className="text-[12px] font-medium text-ink-2">{value === null ? "" : suffix}</span></div></div>129            ))}130          </div>131        </section>132      )}133134      {years.length > 0 && (135        <section>136          <SectionHeader eyebrow="Historique" title="Résultats officiels" description={d.historyNote} />137          <div className="grid md:grid-cols-2 gap-4">138            {years.map((y) => (139              <Card key={y} pad={false}>140                <div className="px-4 py-2.5 border-b border-border flex items-center justify-between"><b>{dateFr(y)}</b><span className="text-[12px] text-ink-3 num">participation {pct(d.history[y][0]?.turnout)}</span></div>141                <table className="data-table"><tbody>{d.history[y].slice(0, 6).map((h, i) => (<tr key={i}><td className="w-6">{h.elected && <span title="Élu·e" className="text-ok">●</span>}</td><td>{h.candidate}</td><td><span className="inline-flex items-center gap-1.5"><PartyDot id={h.party} />{h.party ? partyLabel(h.party) : h.label}</span></td><td className="r num">{int(h.votes)}</td><td className="r num font-semibold">{pct(h.share)}</td></tr>))}</tbody></table>142              </Card>143            ))}144          </div>145        </section>146      )}147148      <Card>149        <div className="eyebrow mb-2">Fiche</div>150        <KV items={[["Code DGEQ", String(d.code)], ["Région (classification QC26)", d.regionName ?? "—"], ["Électeurs inscrits (décret 2026)", int(d.electors)], ["Superficie approx.", d.areaKm2 ? `${int(d.areaKm2)} km²` : "—"], ["Sortant·e", d.incumbent.name ? `${d.incumbent.name} · ${partyName(d.incumbent.party)}` : "—"], ["Volatilité (1 − p favori)", f ? f.volatility.toFixed(2) : "—"], ["Dernière mise à jour", f ? dateTimeFr(f.completedAt) : "—"]]} />151        <div className="mt-3 flex gap-2 flex-wrap"><PartyBadge id={f?.favorite} /><ProbabilityBar p={f?.p ?? 0} color={partyVar(f?.favorite)} className="flex-1 min-w-[200px]" /></div>152      </Card>153    </div>154  );155}156